#
# fusion.db.profile
#
# Copyright 2008 Helsinki Institute for Information Technology
# and the authors.
#
# Authors: Ken Rimey <rimey@hiit.fi>
#

# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

"""
Schema implementation for personal profiles

Our profile schema currently includes four kinds of information:

  Name - typically the full name, unicode text, must not be empty,
  always public.

  Pic - optional profile picture, typically jpeg, always public.

  Profile fields - optional multi-valued text fields, organized in
  sections.  Each value may be either public or restricted (meaning
  friends-only, the default).

  About text - optional multi-line text.  May be either public or
  restricted (the default).

1. Profile fields

The standard sections and fields are as follows (with displayed
English labels in parentheses):

 * location
    - country (Country)
    - region (State/Province)
    - city (City)

 * personal
    - gender (Gender = Male/Female)
    - born (Year of Birth)

 * contact
    - website (Website)
    - phone (Phone)
    - email (Email)
    - im (Instant Messaging)

All fields are optional.

All values can be assumed to be a single line of unicode text.

The standard personal and location fields are single-valued.  All
other fields can be assumed to be multivalued.

The gender value should be either "Male" or "Female".  A good way to
display it in an input form is as a triplet of radio buttons labeled
"Male", "Female", and "Unspecified".

Application developers are free to add support for additional fields
and sections.  The convention for displaying unknown fields is to
capitalize the field name and use that as the label.

It is also acceptable to allow users to define their own fields by
entering both the label and the value.  In this case, the label, which
might already be capitalized, might contain spaces, and might in
general include arbitrary unicode characters, would be used as the
field name.  It would subsequently be treated like any other unknown
field name.

2. Storing a Profile

The first part of this running example shows how to store profile data
from a form, overwriting all of the stored profile data.  In practice,
such a form would have been prefilled with data read from the
database.

>>> from fusion.db.db import DB
>>> db = DB()
>>> iid = db.create_persona()

>>> profile = Profile()
>>> profile.name = u'Test User'
>>> profile.pic = ':-('

>>> profile.add_field('location', 'country', u'Finland', public=True)
>>> profile.add_field('location', 'city', u'Helsinki', public=False)
>>> profile.add_field('location', 'address', u'14 Meadow Lane', public=False)

>>> profile.add_field('contact', 'website', 'http://example.org/', public=True)
>>> profile.add_field('contact', 'phone', '123-4567', public=True)
>>> profile.add_field('contact', 'phone', '765-4321', public=False)
>>> profile.add_field('contact', 'email', 'example@example.com', public=False)
>>> profile.add_field('contact', 'email', 'example@example.org', public=False)

>>> profile.add_field('professional', 'company', u'Acme', public=False)

>>> profile.set_about(u'Hello friends!', public=False)

>>> profile.write(db, iid)

3. Reading a Profile

If we just want to display a profile, we can get the data from the
Profile instance without having to worry about whether it is public or
restricted.  The Profile class includes a display() method, the
purpose of which is to demonstrate how to do this.

>>> profile = Profile()
>>> profile.read(db, iid)

>>> profile.display()
Test User
--
--
Country: Finland
City: Helsinki
Address: 14 Meadow Lane
--
Website: http://example.org/
Phone: 123-4567
Phone: 765-4321
Email: example@example.com
Email: example@example.org
--
Company: Acme
--
About:
Hello friends!

In more detail, the name, picture, and about text can be retrieved as
follows:

>>> profile.name
u'Test User'
>>> profile.pic
':-('
>>> profile.about
u'Hello friends!'

Those values can also be None (but that is abnormal for the name).

If the picture is to be retrieved via a generic HTTP interface to the
datastore, its path can be determined as follows:

>>> profile.pic_path
u'/public/pic.dat'

This value will be None if there is no picture.  If the image is in
jpeg format, the last component of the path will be "pic.jpg".

The intended strategy for displaying the profile fields is to handle
each of the standard sections and then check if there are others, and
for each section, to handle each of the standard fields and then check
if there are others.  The values for a field are retrieved using
list_field_values(), or get_field() if the field is known to be
single-valued:

>>> profile.get_field('location', 'city')
u'Helsinki'
>>> profile.list_field_values('location', 'city')
[u'Helsinki']

>>> profile.get_field('location', 'region')
>>> profile.list_field_values('location', 'region')
[]

After running through the standard fields of the section, we can call
list_field_names() to see if there are any others:

>>> profile.list_field_names('location', exclude=['country', 'region', 'city'])
[u'address']

>>> profile.list_field_values('location', 'address')
[u'14 Meadow Lane']

After running through the standard sections, we can call
list_section_names() to see if there are any others:

>>> profile.list_section_names(exclude=['personal', 'location', 'contact'])
[u'professional']

>>> profile.list_field_names('professional')
[u'company']
>>> profile.list_field_values('professional', 'company')
[u'Acme']

4. Prefilling a Form

In an input form, the profile fields and the about text will each have
a check box to indicate whether it is public (versus restricted to
friends).  Prefilling such a form is a bit more complicated than just
displaying the data, because of the check boxes.

Instead of list_field_values() and get_field(), we call
list_field_values_and_visibility() or get_field_and_visibility() to
additionally return a flag indicating whether each value is public:

>>> profile.get_field_and_visibility('location', 'city')
(u'Helsinki', False)
>>> profile.list_field_values_and_visibility('location', 'city')
[(u'Helsinki', False)]

>>> profile.get_field_and_visibility('location', 'region')
(None, False)
>>> profile.list_field_values_and_visibility('location', 'region')
[]

The interface for querying the "about" text follows a similar pattern:

>>> profile.about
u'Hello friends!'
>>> profile.get_about()
u'Hello friends!'
>>> profile.get_about_and_visibility()
(u'Hello friends!', False)

>>> profile.about = None
>>> profile.get_about_and_visibility()
(None, False)

5. Editing a Profile

Instead of copying all of the data to a form and then creating a
new Profile instance with the updated data, an application can
alternatively modify the instance in place before writing it back out
to the database.  This is an easy way to edit just a few fields while
preserving unknown fields.

>>> profile.read(db, iid)
>>> profile.set_field('location', 'city', u'Espoo', public=True)
>>> profile.get_field('location', 'city')
u'Espoo'
>>> profile.write(db, iid)

More typically, the application programmer might wish to update the
profile fields and about text, and perhaps the name, leaving the
picture as-is.  The clear_all_fields() method is useful for this:

>>> profile.read(db, iid)
>>> profile.clear_all_fields()
>>> profile.about = 'Hello friends!!!'
>>> profile.add_field('personal', 'born', '2001')

>>> profile.pic
':-('
>>> profile.display()
Test User
--
Born: 2001
--
--
--
About:
Hello friends!!!
>>> #profile.write(db, iid)

Alternatively, the application programmer might wish to update the
profile picture, for instance, leaving everything else as-is:

>>> profile.read(db, iid)
>>> profile.pic = ':-)'
>>> profile.write(db, iid)

To round out the doctests, let's try clearing the picture, or
replacing it with a GIF:

>>> profile.read(db, iid)
>>> profile.pic = None
>>> profile.pic
>>> profile.pic_path
>>> #profile.write(db, iid)

>>> profile.read(db, iid)
>>> from binascii import unhexlify
>>> profile.pic = unhexlify('474946383961010001000000003b')
>>> profile.pic_path
u'/public/pic.gif'
>>> #profile.write(db, iid)

5. Reading a Stranger's Profile

Finally, here is a quick test of reading a profile when the restricted
data is not accessible:

>>> db.clear_persona()
>>> profile.read(db, iid)
>>> profile.display()
Test User
--
--
Country: Finland
City: Espoo
--
Website: http://example.org/
Phone: 123-4567
>>> profile.pic
':-)'
"""

import imghdr

class Profile(object):
    def __init__(self):
        self.public = ProfileSlice()
        self.restricted = ProfileSlice()

    def read(self, db, iid):
        self.public.read(db, iid, public=True)
        try:
            self.restricted.read(db, iid, public=False)
        except LookupError:
            self.restricted.clear()

    def write(self, db, iid):
        self.public.write(db, iid, public=True)
        self.restricted.write(db, iid, public=False)

    def get_field(self, section_name, field_name):
        items = self.list_field_values(section_name, field_name)
        return items[0] if items else None

    def get_field_and_visibility(self, section_name, field_name):
        items = self.list_field_values_and_visibility(section_name, field_name)
        if items:
            return items[0]
        else:
            return None, False

    def set_field(self, section_name, field_name, value, public=False):
        self.clear_field(section_name, field_name)
        self.add_field(section_name, field_name, value, public)

    def list_field_values(self, section_name, field_name):
        public_values = self.public.list_field_values(section_name, field_name)
        restricted_values = \
            self.restricted.list_field_values(section_name, field_name)
        return public_values + restricted_values

    def list_field_values_and_visibility(self, section_name, field_name):
        public_values = self.public.list_field_values(section_name, field_name)
        restricted_values = \
            self.restricted.list_field_values(section_name, field_name)
        return ([(v, True) for v in public_values]
                + [(v, False) for v in restricted_values])

    def add_field(self, section_name, field_name, value, public=False):
        if public:
            self.public.add_field(section_name, field_name, value)
        else:
            self.restricted.add_field(section_name, field_name, value)

    def clear_field(self, section_name, field_name):
        self.public.clear_field(section_name, field_name)
        self.restricted.clear_field(section_name, field_name)

    def clear_all_fields(self):
        self.public.profile = None
        self.restricted.profile = None

    def get_name(self):
        return self.public.name

    def set_name(self, name):
        if not name:
            raise ValueError('Name cannot be empty.')
        self.public.name = name

    name = property(get_name, set_name)

    def get_pic(self):
        return self.public.pic_data

    def get_pic_path(self):
        if not self.public.pic_data:
            return None
        else:
            return '/public/' + self.public.pic_name

    def set_pic(self, data):
        if not data:
            self.clear_pic()
        else:
            type = imghdr.what(None, data)
            if not type:
                ext = 'dat'
            elif type == 'jpeg':
                ext = 'jpg'
            else:
                ext = type

            self.public.pic_data = data
            self.public.pic_name = u'pic.' + ext

    def clear_pic(self):
        self.public.pic_data = None
        self.public.pic_name = None

    pic = property(get_pic, set_pic)
    pic_path = property(get_pic_path)

    def get_about(self):
        text, visibility = self.get_about_and_visibility()
        return text

    def get_about_and_visibility(self):
        if self.public.about:
            return self.public.about, True
        else:
            return self.restricted.about, False

    def set_about(self, text, public=False):
        if public:
            self.public.about = text
            self.restricted.about = None
        else:
            self.public.about = None
            self.restricted.about = text

    def clear_about(self):
        """
        Clear the "about" text.

        Any of the following will also work:

            profile.about = None
            profile.about = ''
            profile.set_about(None)
            profile.set_about('')
        """
        self.public.about = None
        self.restricted.about = None

    about = property(get_about, set_about)

    def list_section_names(self, exclude=[]):
        result = set(self.public.list_section_names(exclude))
        result.update(self.restricted.list_section_names(exclude))
        return sorted(result)

    def list_field_names(self, section_name, exclude=[]):
        result = set(self.public.list_field_names(section_name, exclude))
        result.update(self.restricted.list_field_names(section_name, exclude))
        return sorted(result)

    def display(self):
        def show(section, items=[]):
            for label, field in items:
                values = self.list_field_values(section, field)
                for value in values:
                    print label, value
            fields = [field for label, field in items]
            for field in self.list_field_names(section, exclude=fields):
                label = field.capitalize() + ':'
                values = self.list_field_values(section, field)
                for value in values:
                    print label, value

        print self.name
        print '--'
        show('personal', [('Born:', 'born'),
                          ('Gender:', 'gender')])
        print '--'
        show('location', [('Country:', 'country'),
                          ('State/Province:', 'region'),
                          ('City:', 'city')])
        print '--'
        show('contact', [('Website:', 'website'),
                         ('Phone:', 'phone'),
                         ('Email:', 'email'),
                         ('Instant Messaging:', 'im')])

        sections = ['personal', 'location', 'contact']
        for section in self.list_section_names(exclude=sections):
            print '--'
            show(section)

        if self.about:
            print '--'
            print 'About:'
            print self.about

class ProfileSlice(object):
    def __init__(self):
        self.clear()

    def clear(self):
        self.name = None
        self.profile = None
        self.about = None
        self.pic_data = None
        self.pic_name = None

    def read(self, db, iid, public=True):
        if public:
            base = '/public/'
            cipher = None
        else:
            base = '/restricted/'
            cipher = db.get_domain_cipher(iid)

        self.name = db.get_text(iid, base + 'name.txt', cipher=cipher)
        self.profile = db.get_json(iid, base + 'profile.json', cipher=cipher)
        self.about = db.get_text(iid, base + 'about.txt', cipher=cipher)

        for name in db.keys(iid, base):
            if name.startswith('pic.'):
                self.pic_data = db.get(iid, base + name, cipher=cipher)
                self.pic_name = name

    def write(self, db, iid, public=True):
        if public:
            base = '/public/'
            cipher = None
        else:
            base = '/restricted/'
            cipher = db.get_domain_cipher(iid)

        for name, data in [('name.txt', self.name),
                           ('profile.json', self.profile),
                           ('about.txt', self.about)]:
            if data:
                # XXX Don't overwrite a value with an identical one
                # when encrypting (nondeterministically).
                db.put(iid, base + name, data, cipher=cipher)
            else:
                db.delete(iid, base + name)

        data = self.pic_data
        name = self.pic_name if data else None

        if name:
            db.put(iid, base + name, data, cipher=cipher)

        for other in db.keys(iid, base):
            if other.startswith('pic.') and other != name:
                db.delete(iid, base + other)

    def list_field_values(self, section_name, field_name):
        profile = self.profile or {}
        section = profile.get(section_name, {})
        value = section.get(field_name)
        if isinstance(value, list):
            return value
        elif value:
            return [value]
        else:
            return []

    def add_field(self, section_name, field_name, value):
        if value:
            self.profile = self.profile or {}
            section = self.profile.setdefault(section_name, {})
            current = section.get(field_name)
            if isinstance(current, list):
                current.append(value)
            elif current:
                section[field_name] = [current, value]
            else:
                section[field_name] = value

    def clear_field(self, section_name, field_name):
        if self.profile and section_name in self.profile:
            section = self.profile[section_name]
            if field_name in section:
                del section[field_name]
            if not section:
                del self.profile[section_name]

    def list_section_names(self, exclude=[]):
        profile = self.profile or {}
        return sorted(set(profile.keys()).difference(exclude))

    def list_field_names(self, section_name, exclude=[]):
        profile = self.profile or {}
        section = profile.get(section_name, {})
        return sorted(set(section.keys()).difference(exclude))

if __name__ == '__main__':
    import doctest
    doctest.testmod()
